From 03b4f848077551aaef6c66944ed8c066c16c98c5 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Fri, 2 Feb 2024 13:55:22 +0100 Subject: [PATCH] Restructure workout events table --- HealthImport.xcodeproj/project.pbxproj | 24 +-- HealthImport/HealthDatabase.swift | 15 +- .../Model/HKWorkoutEvent+Identifiable.swift | 10 + HealthImport/Model/Tables/ObjectsTable.swift | 2 +- .../Model/Tables/QuantitySamplesTable.swift | 2 +- HealthImport/Model/Tables/SamplesTable.swift | 8 + .../Model/Tables/WorkoutEventsTable.swift | 174 ++++++++++++++++++ HealthImport/Model/Tables/WorkoutsTable.swift | 97 ++++++++++ HealthImport/Model/Workout+SQLite.swift | 83 --------- HealthImport/Model/WorkoutEvent+SQLite.swift | 90 --------- HealthImport/Model/WorkoutEvent.swift | 95 ---------- .../Preview Content/HealthDatabase+Mock.swift | 19 +- .../Preview Content/WorkoutEvent+Mock.swift | 6 +- 13 files changed, 325 insertions(+), 300 deletions(-) create mode 100644 HealthImport/Model/HKWorkoutEvent+Identifiable.swift create mode 100644 HealthImport/Model/Tables/WorkoutEventsTable.swift create mode 100644 HealthImport/Model/Tables/WorkoutsTable.swift delete mode 100644 HealthImport/Model/Workout+SQLite.swift delete mode 100644 HealthImport/Model/WorkoutEvent+SQLite.swift delete mode 100644 HealthImport/Model/WorkoutEvent.swift diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index d0df140..be518e4 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -15,9 +15,9 @@ 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002702B5C299900E7D4DB /* HealthDatabase.swift */; }; 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 885002762B5C2FC400E7D4DB /* SQLite */; }; 885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002782B5C320400E7D4DB /* Optional+Extensions.swift */; }; - 8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */; }; + 8850027B2B5C35BF00E7D4DB /* WorkoutsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */; }; 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850027E2B5C36A700E7D4DB /* Workout.swift */; }; - 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */; }; + 885002852B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002842B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift */; }; 885002872B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */; }; 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */; }; 8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */; }; @@ -50,7 +50,7 @@ E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */; }; E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */; }; E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */; }; - E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */; }; + E27BC68E2B5FCBD5003A8873 /* WorkoutEventsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */; }; E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; }; E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */; }; E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6932B5FD587003A8873 /* Workout+Mock.swift */; }; @@ -76,9 +76,9 @@ 8850026B2B5C278600E7D4DB /* healthdb_secure.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = healthdb_secure.sqlite; sourceTree = ""; }; 885002702B5C299900E7D4DB /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = ""; }; 885002782B5C320400E7D4DB /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; - 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+SQLite.swift"; sourceTree = ""; }; + 8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutsTable.swift; sourceTree = ""; }; 8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = ""; }; - 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEvent.swift; sourceTree = ""; }; + 885002842B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutEvent+Identifiable.swift"; sourceTree = ""; }; 885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Extensions.swift"; sourceTree = ""; }; 8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = ""; }; 8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; @@ -108,7 +108,7 @@ E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuantitySamplesTable.swift; sourceTree = ""; }; E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStringsTable.swift; sourceTree = ""; }; E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = ""; }; - E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = ""; }; + E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsTable.swift; sourceTree = ""; }; E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = ""; }; E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = ""; }; E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = ""; }; @@ -216,10 +216,8 @@ 8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */, E27BC6852B5FBF0B003A8873 /* Sample.swift */, 8850027E2B5C36A700E7D4DB /* Workout.swift */, - 8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */, E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */, - 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, - E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */, + 885002842B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift */, ); path = Model; sourceTree = ""; @@ -259,6 +257,8 @@ E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */, E201EC7C2B62930E005B83D3 /* SamplesTable.swift */, E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */, + E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */, + 8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */, ); path = Tables; sourceTree = ""; @@ -361,7 +361,7 @@ E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */, E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */, 8850028F2B5D0EAF00E7D4DB /* Date+Extensions.swift in Sources */, - 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */, + 885002852B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift in Sources */, 885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */, E2FDFF182B6BB61D0080A7B3 /* HKHealthStoreInterface.swift in Sources */, E27BC6922B5FD488003A8873 /* HealthDatabase+Mock.swift in Sources */, @@ -375,7 +375,7 @@ 8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */, E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */, E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */, - 8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */, + 8850027B2B5C35BF00E7D4DB /* WorkoutsTable.swift in Sources */, E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */, E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */, @@ -395,7 +395,7 @@ E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */, E201EC812B631708005B83D3 /* Goal.swift in Sources */, E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */, - E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */, + E27BC68E2B5FCBD5003A8873 /* WorkoutEventsTable.swift in Sources */, 8850025B2B5C273C00E7D4DB /* HealthImportApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift index 31ff64a..96eaef6 100644 --- a/HealthImport/HealthDatabase.swift +++ b/HealthImport/HealthDatabase.swift @@ -13,6 +13,8 @@ final class HealthDatabase: ObservableObject { private let samples: SamplesTable + private let workoutsTable: WorkoutsTable + @Published var workouts: [Workout] = [] @@ -25,6 +27,8 @@ final class HealthDatabase: ObservableObject { self.fileUrl = fileUrl self.database = database self.samples = .init(database: database) + self.workoutsTable = .init(database: database) + DispatchQueue.global().async { self.readAllWorkouts() } @@ -33,7 +37,7 @@ final class HealthDatabase: ObservableObject { func readAllWorkouts() { let workouts: [Workout] do { - workouts = try WorkoutTable.readAll(in: database) + workouts = try workoutsTable.workouts() } catch { print("Failed to read workouts: \(error)") return @@ -83,6 +87,10 @@ final class HealthDatabase: ObservableObject { self.init(fileUrl: .init(filePath: "/"), database: database) } + func insert(workout: Workout) throws { + try workoutsTable.insert(workout) + } + func insert(workout: Workout, into store: HKHealthStore) async throws -> HKWorkout? { guard let configuration = workout.activities.first?.workoutConfiguration else { return nil @@ -91,6 +99,11 @@ final class HealthDatabase: ObservableObject { let builder = HKWorkoutBuilder(healthStore: store, configuration: configuration, device: nil) return try await builder.finishWorkout() } + + func createTables() throws { + try samples.createAll() + try workoutsTable.createAll() + } } private extension HKWorkoutActivity { diff --git a/HealthImport/Model/HKWorkoutEvent+Identifiable.swift b/HealthImport/Model/HKWorkoutEvent+Identifiable.swift new file mode 100644 index 0000000..64476bf --- /dev/null +++ b/HealthImport/Model/HKWorkoutEvent+Identifiable.swift @@ -0,0 +1,10 @@ +import Foundation +import HealthKit +import SwiftProtobuf + +extension HKWorkoutEvent: Identifiable { + + public var id: Double { + dateInterval.start.timeIntervalSinceReferenceDate * Double(type.rawValue) * dateInterval.duration + } +} diff --git a/HealthImport/Model/Tables/ObjectsTable.swift b/HealthImport/Model/Tables/ObjectsTable.swift index 9430221..e7332eb 100644 --- a/HealthImport/Model/Tables/ObjectsTable.swift +++ b/HealthImport/Model/Tables/ObjectsTable.swift @@ -9,7 +9,7 @@ struct ObjectsTable { self.database = database } - func create() throws { + func create(referencing dataProvenances: DataProvenancesTable) throws { try database.execute("CREATE TABLE objects (data_id INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE, provenance INTEGER NOT NULL REFERENCES data_provenances (ROWID) ON DELETE CASCADE, type INTEGER, creation_date REAL)") } diff --git a/HealthImport/Model/Tables/QuantitySamplesTable.swift b/HealthImport/Model/Tables/QuantitySamplesTable.swift index d9def06..564d15f 100644 --- a/HealthImport/Model/Tables/QuantitySamplesTable.swift +++ b/HealthImport/Model/Tables/QuantitySamplesTable.swift @@ -9,7 +9,7 @@ struct QuantitySamplesTable { self.database = database } - func create() throws { + func create(referencing unitStrings: UnitStringsTable) throws { try database.execute("CREATE TABLE quantity_samples (data_id INTEGER PRIMARY KEY, quantity REAL, original_quantity REAL, original_unit INTEGER REFERENCES unit_strings (ROWID) ON DELETE NO ACTION)") } diff --git a/HealthImport/Model/Tables/SamplesTable.swift b/HealthImport/Model/Tables/SamplesTable.swift index 1cc6311..fb665e8 100644 --- a/HealthImport/Model/Tables/SamplesTable.swift +++ b/HealthImport/Model/Tables/SamplesTable.swift @@ -25,6 +25,14 @@ struct SamplesTable { try database.execute("CREATE TABLE samples (data_id INTEGER PRIMARY KEY, start_date REAL, end_date REAL, data_type INTEGER)") } + func createAll() throws { + try create() + try unitStrings.create() + try quantitySamples.create(referencing: unitStrings) + try dataProvenances.create() + try objects.create(referencing: dataProvenances) + } + private let table = Table("samples") private let dataId = Expression("data_id") diff --git a/HealthImport/Model/Tables/WorkoutEventsTable.swift b/HealthImport/Model/Tables/WorkoutEventsTable.swift new file mode 100644 index 0000000..c588a3e --- /dev/null +++ b/HealthImport/Model/Tables/WorkoutEventsTable.swift @@ -0,0 +1,174 @@ +import Foundation +import SQLite +import HealthKit + +struct WorkoutEventsTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + let table = Table("workout_events") + + // INTEGER PRIMARY KEY AUTOINCREMENT + let rowId = Expression("ROWID") + + // owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE + let ownerId = Expression("owner_id") + + // date REAL NOT NULL + let date = Expression("date") + + // type INTEGER NOT NULL + let type = Expression("type") + + // duration REAL NOT NULL + let duration = Expression("duration") + + // metadata BLOB + let metadata = Expression("metadata") + + // session_uuid BLOB + let sessionUUID = Expression("session_uuid") + + // error BLOB + let error = Expression("error") + + func events(in database: Connection) throws -> [HKWorkoutEvent] { + try database.prepare(table).map(event) + } + + func events(for workoutId: Int, in database: Connection) throws -> [HKWorkoutEvent] { + try database.prepare(table.filter(ownerId == workoutId)).map(event) + } + + private func event(from row: Row) -> HKWorkoutEvent { + let start = Date(timeIntervalSinceReferenceDate: row[date]) + let interval = DateInterval(start: start, duration: row[duration]) + let metadata = metadata(row[metadata]) + let type = HKWorkoutEventType(rawValue: row[type])! + // let sessionUUID = row[sessionUUID] + // let error = row[rrror] + return .init(type: type, dateInterval: interval, metadata: metadata) + } + + private func metadata(_ data: Data?) -> [String : Any] { + guard let data else { + return [:] + } + return WorkoutEventsTable.decode(metadata: data) + } + + func create(referencing workouts: WorkoutsTable) throws { + // try database.execute("CREATE TABLE workout_events (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE, date REAL NOT NULL, type INTEGER NOT NULL, duration REAL NOT NULL, metadata BLOB, session_uuid BLOB, error BLOB)") + try database.run(table.create { t in + t.column(rowId, primaryKey: .autoincrement) + t.column(ownerId, references: workouts.table, workouts.dataId) + t.column(date) + t.column(type) + t.column(duration) + t.column(metadata) + t.column(sessionUUID) + t.column(error) + }) + } + + func insert(_ element: HKWorkoutEvent, dataId: Int) throws { + try database.run(table.insert( + ownerId <- dataId, + date <- element.dateInterval.start.timeIntervalSinceReferenceDate, + type <- element.type.rawValue, + duration <- element.dateInterval.duration, + metadata <- WorkoutEventsTable.encode(metadata: element.metadata ?? [:])) + // SessionUUID <- element.sessionUUID + // Error <- element.error) + ) + } +} + +extension WorkoutEventsTable { + + static func decode(metadata data: Data) -> [String : Any] { + let metadata: WorkoutEventMetadata + do { + metadata = try WorkoutEventMetadata(serializedData: data) + } catch { + print("Failed to decode event metadata: \(error)") + print(data.hex) + return [:] + } + + return metadata.elements.reduce(into: [:]) { dict, element in + guard let value = element.value else { + print("No value for metadata element \(element)") + print(data.hex) + return + } + dict[element.key] = value + } + } + + static func encode(metadata: [String : Any]) -> Data? { + let wrapper = WorkoutEventMetadata.with { + $0.elements = metadata.compactMap { .from(key: $0.key, value: $0.value) } + } + guard !wrapper.elements.isEmpty else { + return nil + } + do { + return try wrapper.serializedData() + } catch { + print("Failed to encode event metadata: \(error)") + return nil + } + } +} + +private extension WorkoutEventMetadata.Element { + + var value: Any? { + if hasUnsignedValue { + return unsignedValue + } + if hasQuantity { + return HKQuantity(unit: .init(from: quantity.unit), doubleValue: quantity.value) + } + return UInt(0) + } + + static func from(key: String, value: Any) -> Self? { + if let value = value as? UInt64 { + return .with { + $0.key = key + $0.unsignedValue = UInt64(value) + } + } + guard let value = value as? HKQuantity else { + print("Unknown value type for metadata key \(key): \(value)") + return nil + } + + let number: Double + let unit: String + if value.is(compatibleWith: .meter()) { + number = value.doubleValue(for: .meter()) + unit = "m" + } else if value.is(compatibleWith: .second()) { + number = value.doubleValue(for: .second()) + unit = "s" + } else { + print("Unhandled quantity type for metadata key \(key): \(value)") + return nil + } + + return .with { el in + el.key = key + el.quantity = .with { + $0.value = number + $0.unit = unit + } + } + } +} diff --git a/HealthImport/Model/Tables/WorkoutsTable.swift b/HealthImport/Model/Tables/WorkoutsTable.swift new file mode 100644 index 0000000..e54132a --- /dev/null +++ b/HealthImport/Model/Tables/WorkoutsTable.swift @@ -0,0 +1,97 @@ +import Foundation +import SQLite +import HealthKit + +struct WorkoutsTable { + + private let database: Connection + + let events: WorkoutEventsTable + + init(database: Connection) { + self.database = database + self.events = .init(database: database) + } + + let table = Table("workouts") + + // INTEGER PRIMARY KEY AUTOINCREMENT + let dataId = Expression("data_id") + + // REAL + let totalDistance = Expression("total_distance") + + // INTEGER + let goalType = Expression("goal_type") + + // REAL + let goal = Expression("goal") + + // INTEGER + let condenserVersion = Expression("condenser_version") + + // REAL + let condenserDate = Expression("condenser_date") + + func workouts() throws -> [Workout] { + let metadataKeys = try Metadata.allKeys(in: database) + + return try database.prepare(table).map { row in + let id = row[dataId] + + let events = try events.events(for: id, in: database) + let activities = try WorkoutActivityTable.activities(for: id, in: database) + let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys) + return .init( + id: id, + totalDistance: row[totalDistance], + goalType: row[goalType], + goal: row[goal], + condenserVersion: row[condenserVersion], + condenserDate: row[condenserDate].map { Date.init(timeIntervalSinceReferenceDate: $0) }, + events: events, + activities: activities, + metadata: metadata) + } + } + + func create() throws { + try database.run(table.create { t in + t.column(dataId, primaryKey: .autoincrement) + t.column(totalDistance) + t.column(goalType) + t.column(goal) + t.column(condenserVersion) + t.column(condenserDate) + }) + // try database.execute("CREATE TABLE workouts (data_id INTEGER PRIMARY KEY AUTOINCREMENT, total_distance REAL, goal_type INTEGER, goal REAL, condenser_version INTEGER, condenser_date REAL)") + } + + func createAll() throws { + try create() + try events.create(referencing: self) + } + + func insert(_ element: Workout) throws { + let rowid = try database.run(table.insert( + totalDistance <- element.totalDistance, + goalType <- element.goal?.goalType, + goal <- element.goal?.gaol, + condenserVersion <- element.condenserVersion, + condenserDate <- element.condenserDate?.timeIntervalSinceReferenceDate) + ) + let dataId = Int(rowid) + for event in element.events { + try events.insert(event, dataId: dataId) + } + + for activity in element.activities { + try WorkoutActivityTable.insert(activity, isPrimaryActivity: true, dataId: dataId, in: database) + } + + for (key, value) in element.metadata { + try Metadata.insert(value, for: key, of: dataId, in: database) + } + } +} + diff --git a/HealthImport/Model/Workout+SQLite.swift b/HealthImport/Model/Workout+SQLite.swift deleted file mode 100644 index 76371dc..0000000 --- a/HealthImport/Model/Workout+SQLite.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -import SQLite -import HealthKit - -enum WorkoutTable { - - private static let table = Table("workouts") - - // INTEGER PRIMARY KEY AUTOINCREMENT - private static let columnDataId = Expression("data_id") - - // REAL - private static let columnTotalDistance = Expression("total_distance") - - // INTEGER - private static let columnGoalType = Expression("goal_type") - - // REAL - private static let columnGoal = Expression("goal") - - // INTEGER - private static let columnCondenserVersion = Expression("condenser_version") - - // REAL - private static let columnCondenserDate = Expression("condenser_date") - - static func readAll(in database: Connection) throws -> [Workout] { - let metadataKeys = try Metadata.allKeys(in: database) - - return try database.prepare(table).map { row in - let id = row[columnDataId] - - let events = try HKWorkoutEventTable.events(for: id, in: database) - let activities = try WorkoutActivityTable.activities(for: id, in: database) - let metadata = try Metadata.metadata(for: id, in: database, keyMap: metadataKeys) - return .init( - id: id, - totalDistance: row[columnTotalDistance], - goalType: row[columnGoalType], - goal: row[columnGoal], - condenserVersion: row[columnCondenserVersion], - condenserDate: row[columnCondenserDate].map { Date.init(timeIntervalSinceReferenceDate: $0) }, - events: events, - activities: activities, - metadata: metadata) - } - } - - static func create(in database: Database) throws { - try database.run(table.create { t in - t.column(columnDataId, primaryKey: .autoincrement) - t.column(columnTotalDistance) - t.column(columnGoalType) - t.column(columnGoal) - t.column(columnCondenserVersion) - t.column(columnCondenserDate) - }) - // try database.execute("CREATE TABLE workouts (data_id INTEGER PRIMARY KEY AUTOINCREMENT, total_distance REAL, goal_type INTEGER, goal REAL, condenser_version INTEGER, condenser_date REAL)") - } - - static func insert(_ element: Workout, in database: Database) throws { - let rowid = try database.run(table.insert( - columnTotalDistance <- element.totalDistance, - columnGoalType <- element.goal?.goalType, - columnGoal <- element.goal?.gaol, - columnCondenserVersion <- element.condenserVersion, - columnCondenserDate <- element.condenserDate?.timeIntervalSinceReferenceDate) - ) - let dataId = Int(rowid) - for event in element.events { - try event.insert(in: database, dataId: dataId) - } - - for activity in element.activities { - try WorkoutActivityTable.insert(activity, isPrimaryActivity: true, dataId: dataId, in: database) - } - - for (key, value) in element.metadata { - try Metadata.insert(value, for: key, of: dataId, in: database) - } - } -} - diff --git a/HealthImport/Model/WorkoutEvent+SQLite.swift b/HealthImport/Model/WorkoutEvent+SQLite.swift deleted file mode 100644 index e918c5e..0000000 --- a/HealthImport/Model/WorkoutEvent+SQLite.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import SQLite -import HealthKit - -enum HKWorkoutEventTable { - - private static let table = Table("workout_events") - - // INTEGER PRIMARY KEY AUTOINCREMENT - private static let columnRowId = Expression("ROW_ID") - - // owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE - private static let columnOwnerId = Expression("owner_id") - - // date REAL NOT NULL - private static let columnDate = Expression("date") - - // type INTEGER NOT NULL - private static let columnType = Expression("type") - - // duration REAL NOT NULL - private static let columnDuration = Expression("duration") - - // metadata BLOB - private static let columnMetadata = Expression("metadata") - - // session_uuid BLOB - private static let columnSessionUUID = Expression("session_uuid") - - // error BLOB - private static let columnError = Expression("error") - - static func readAll(in database: Connection) throws -> [HKWorkoutEvent] { - try database.prepare(table).map(event) - } - - static func events(for workoutId: Int, in database: Connection) throws -> [HKWorkoutEvent] { - try database.prepare(table.filter(columnOwnerId == workoutId)).map(event) - } - - private static func event(from row: Row) -> HKWorkoutEvent { - let start = Date(timeIntervalSinceReferenceDate: row[columnDate]) - let interval = DateInterval(start: start, duration: row[columnDuration]) - let metadata = metadata(row[columnMetadata]) - let type = HKWorkoutEventType(rawValue: row[columnType])! - // let sessionUUID = row[columnSessionUUID] - // let error = row[columnError] - return .init(type: type, dateInterval: interval, metadata: metadata) - } - - private static func metadata(_ data: Data?) -> [String : Any] { - guard let data else { - return [:] - } - return decode(metadata: data) - } - - static func create(in database: Database) throws { - // try database.execute("CREATE TABLE workout_events (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE, date REAL NOT NULL, type INTEGER NOT NULL, duration REAL NOT NULL, metadata BLOB, session_uuid BLOB, error BLOB)") - try database.run(table.create { t in - t.column(columnRowId, primaryKey: .autoincrement) - t.column(columnOwnerId, references: Table("workouts"), Expression("data_id")) - t.column(columnDate) - t.column(columnType) - t.column(columnDuration) - t.column(columnMetadata) - t.column(columnSessionUUID) - t.column(columnError) - }) - } - - static func insert(_ element: HKWorkoutEvent, dataId: Int, in database: Database) throws { - try database.run(table.insert( - columnOwnerId <- dataId, - columnDate <- element.dateInterval.start.timeIntervalSinceReferenceDate, - columnType <- element.type.rawValue, - columnDuration <- element.dateInterval.duration, - columnMetadata <- encode(metadata: element.metadata ?? [:])) - // columnSessionUUID <- element.sessionUUID - // columnError <- element.error) - ) - } -} - -extension HKWorkoutEvent { - - func insert(in database: Database, dataId: Int) throws { - try HKWorkoutEventTable.insert(self, dataId: dataId, in: database) - } -} diff --git a/HealthImport/Model/WorkoutEvent.swift b/HealthImport/Model/WorkoutEvent.swift deleted file mode 100644 index d8aa31f..0000000 --- a/HealthImport/Model/WorkoutEvent.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import HealthKit -import SwiftProtobuf - -extension HKWorkoutEvent: Identifiable { - - public var id: Double { - dateInterval.start.timeIntervalSinceReferenceDate * Double(type.rawValue) * dateInterval.duration - } -} - -extension HKWorkoutEventTable { - - static func decode(metadata data: Data) -> [String : Any] { - let metadata: WorkoutEventMetadata - do { - metadata = try WorkoutEventMetadata(serializedData: data) - } catch { - print("Failed to decode event metadata: \(error)") - print(data.hex) - return [:] - } - - return metadata.elements.reduce(into: [:]) { dict, element in - guard let value = element.value else { - print("No value for metadata element \(element)") - print(data.hex) - return - } - dict[element.key] = value - } - } - - static func encode(metadata: [String : Any]) -> Data? { - let wrapper = WorkoutEventMetadata.with { - $0.elements = metadata.compactMap { .from(key: $0.key, value: $0.value) } - } - guard !wrapper.elements.isEmpty else { - return nil - } - do { - return try wrapper.serializedData() - } catch { - print("Failed to encode event metadata: \(error)") - return nil - } - } -} - -private extension WorkoutEventMetadata.Element { - - var value: Any? { - if hasUnsignedValue { - return unsignedValue - } - if hasQuantity { - return HKQuantity(unit: .init(from: quantity.unit), doubleValue: quantity.value) - } - return UInt(0) - } - - static func from(key: String, value: Any) -> Self? { - if let value = value as? UInt64 { - return .with { - $0.key = key - $0.unsignedValue = UInt64(value) - } - } - guard let value = value as? HKQuantity else { - print("Unknown value type for metadata key \(key): \(value)") - return nil - } - - let number: Double - let unit: String - if value.is(compatibleWith: .meter()) { - number = value.doubleValue(for: .meter()) - unit = "m" - } else if value.is(compatibleWith: .second()) { - number = value.doubleValue(for: .second()) - unit = "s" - } else { - print("Unhandled quantity type for metadata key \(key): \(value)") - return nil - } - - return .with { el in - el.key = key - el.quantity = .with { - $0.value = number - $0.unit = unit - } - } - } -} diff --git a/HealthImport/Preview Content/HealthDatabase+Mock.swift b/HealthImport/Preview Content/HealthDatabase+Mock.swift index 74545a7..822e235 100644 --- a/HealthImport/Preview Content/HealthDatabase+Mock.swift +++ b/HealthImport/Preview Content/HealthDatabase+Mock.swift @@ -7,23 +7,14 @@ extension HealthDatabase { static func mock() -> HealthDatabase { do { - let database = try makeDatabase() - return .init(database: database) + let connection = try Connection(.inMemory) + let database = HealthDatabase(database: connection) + try database.createTables() + try database.insert(workout: .mock1) + return database } catch { print(error) fatalError("Failed to create mock database: \(error)") } } - - private static func makeDatabase() throws -> Connection { - let database = try Connection(.inMemory) - - try WorkoutTable.create(in: database) - try HKWorkoutEventTable.create(in: database) - try WorkoutActivityTable.create(in: database) - try Metadata.createTables(in: database) - - try WorkoutTable.insert(.mock1, in: database) - return database - } } diff --git a/HealthImport/Preview Content/WorkoutEvent+Mock.swift b/HealthImport/Preview Content/WorkoutEvent+Mock.swift index 2b3fcf9..ff633f5 100644 --- a/HealthImport/Preview Content/WorkoutEvent+Mock.swift +++ b/HealthImport/Preview Content/WorkoutEvent+Mock.swift @@ -8,15 +8,15 @@ extension HKWorkoutEvent { .init(type: .init(rawValue: 7)!, dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702107518.84307), duration: 1114.56374406815), - metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event1Metadata)!)), + metadata: WorkoutEventsTable.decode(metadata: .init(hex: mock1Event1Metadata)!)), .init(type: .init(rawValue: 7)!, dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702107518.84307), duration: 1972.17168283463), - metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event2Metadata)!)), + metadata: WorkoutEventsTable.decode(metadata: .init(hex: mock1Event2Metadata)!)), .init(type: .init(rawValue: 1)!, dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702112942.707113), duration: 0.0), - metadata: HKWorkoutEventTable.decode(metadata: .init(hex: mock1Event2Metadata)!)), + metadata: WorkoutEventsTable.decode(metadata: .init(hex: mock1Event2Metadata)!)), .init(type: .init(rawValue: 2)!, dateInterval: .init(start: Date(timeIntervalSinceReferenceDate: 702113161.221132), duration: 0.0),