Restructure workout events table

This commit is contained in:
Christoph Hagen 2024-02-02 13:55:22 +01:00
parent da0e758b35
commit 03b4f84807
13 changed files with 325 additions and 300 deletions

View File

@ -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;

View File

@ -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 {

View 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
}
}

View File

@ -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)")
}

View File

@ -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)")
}

View File

@ -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")

View 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
}
}
}
}

View 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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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
}
}

View File

@ -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),