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

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