Restructure workout events table
This commit is contained in:
parent
da0e758b35
commit
03b4f84807
@ -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 = "<group>"; };
|
||||
885002702B5C299900E7D4DB /* HealthDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDatabase.swift; sourceTree = "<group>"; };
|
||||
885002782B5C320400E7D4DB /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
|
||||
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+SQLite.swift"; sourceTree = "<group>"; };
|
||||
8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutsTable.swift; sourceTree = "<group>"; };
|
||||
8850027E2B5C36A700E7D4DB /* Workout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workout.swift; sourceTree = "<group>"; };
|
||||
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEvent.swift; sourceTree = "<group>"; };
|
||||
885002842B5C7AD600E7D4DB /* HKWorkoutEvent+Identifiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutEvent+Identifiable.swift"; sourceTree = "<group>"; };
|
||||
885002862B5C7FA900E7D4DB /* HKWorkoutActivityType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivityType+Extensions.swift"; sourceTree = "<group>"; };
|
||||
8850028C2B5D0B5000E7D4DB /* WorkoutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutDetailView.swift; sourceTree = "<group>"; };
|
||||
8850028E2B5D0EAF00E7D4DB /* Date+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||
@ -108,7 +108,7 @@
|
||||
E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuantitySamplesTable.swift; sourceTree = "<group>"; };
|
||||
E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStringsTable.swift; sourceTree = "<group>"; };
|
||||
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = "<group>"; };
|
||||
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutEventsTable.swift; sourceTree = "<group>"; };
|
||||
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = "<group>"; };
|
||||
E27BC6912B5FD488003A8873 /* HealthDatabase+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HealthDatabase+Mock.swift"; sourceTree = "<group>"; };
|
||||
E27BC6932B5FD587003A8873 /* Workout+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workout+Mock.swift"; sourceTree = "<group>"; };
|
||||
@ -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 = "<group>";
|
||||
@ -259,6 +257,8 @@
|
||||
E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */,
|
||||
E201EC7C2B62930E005B83D3 /* SamplesTable.swift */,
|
||||
E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */,
|
||||
E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */,
|
||||
8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */,
|
||||
);
|
||||
path = Tables;
|
||||
sourceTree = "<group>";
|
||||
@ -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;
|
||||
|
@ -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 {
|
||||
|
10
HealthImport/Model/HKWorkoutEvent+Identifiable.swift
Normal file
10
HealthImport/Model/HKWorkoutEvent+Identifiable.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
@ -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)")
|
||||
}
|
||||
|
||||
|
@ -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)")
|
||||
}
|
||||
|
||||
|
@ -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<Int>("data_id")
|
||||
|
174
HealthImport/Model/Tables/WorkoutEventsTable.swift
Normal file
174
HealthImport/Model/Tables/WorkoutEventsTable.swift
Normal file
@ -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<Int>("ROWID")
|
||||
|
||||
// owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE
|
||||
let ownerId = Expression<Int>("owner_id")
|
||||
|
||||
// date REAL NOT NULL
|
||||
let date = Expression<Double>("date")
|
||||
|
||||
// type INTEGER NOT NULL
|
||||
let type = Expression<Int>("type")
|
||||
|
||||
// duration REAL NOT NULL
|
||||
let duration = Expression<Double>("duration")
|
||||
|
||||
// metadata BLOB
|
||||
let metadata = Expression<Data?>("metadata")
|
||||
|
||||
// session_uuid BLOB
|
||||
let sessionUUID = Expression<Data?>("session_uuid")
|
||||
|
||||
// error BLOB
|
||||
let error = Expression<Data?>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
97
HealthImport/Model/Tables/WorkoutsTable.swift
Normal file
97
HealthImport/Model/Tables/WorkoutsTable.swift
Normal file
@ -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<Int>("data_id")
|
||||
|
||||
// REAL
|
||||
let totalDistance = Expression<Double?>("total_distance")
|
||||
|
||||
// INTEGER
|
||||
let goalType = Expression<Int?>("goal_type")
|
||||
|
||||
// REAL
|
||||
let goal = Expression<Double?>("goal")
|
||||
|
||||
// INTEGER
|
||||
let condenserVersion = Expression<Int?>("condenser_version")
|
||||
|
||||
// REAL
|
||||
let condenserDate = Expression<Double?>("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<Int>("data_id")
|
||||
|
||||
// REAL
|
||||
private static let columnTotalDistance = Expression<Double?>("total_distance")
|
||||
|
||||
// INTEGER
|
||||
private static let columnGoalType = Expression<Int?>("goal_type")
|
||||
|
||||
// REAL
|
||||
private static let columnGoal = Expression<Double?>("goal")
|
||||
|
||||
// INTEGER
|
||||
private static let columnCondenserVersion = Expression<Int?>("condenser_version")
|
||||
|
||||
// REAL
|
||||
private static let columnCondenserDate = Expression<Double?>("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<Int>("ROW_ID")
|
||||
|
||||
// owner_id INTEGER NOT NULL REFERENCES workouts (data_id) ON DELETE CASCADE
|
||||
private static let columnOwnerId = Expression<Int>("owner_id")
|
||||
|
||||
// date REAL NOT NULL
|
||||
private static let columnDate = Expression<Double>("date")
|
||||
|
||||
// type INTEGER NOT NULL
|
||||
private static let columnType = Expression<Int>("type")
|
||||
|
||||
// duration REAL NOT NULL
|
||||
private static let columnDuration = Expression<Double>("duration")
|
||||
|
||||
// metadata BLOB
|
||||
private static let columnMetadata = Expression<Data?>("metadata")
|
||||
|
||||
// session_uuid BLOB
|
||||
private static let columnSessionUUID = Expression<Data?>("session_uuid")
|
||||
|
||||
// error BLOB
|
||||
private static let columnError = Expression<Data?>("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<Int>("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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
Loading…
Reference in New Issue
Block a user