diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 8963994..86ef8c3 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -41,7 +41,7 @@ E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7C2B62930E005B83D3 /* SamplesTable.swift */; }; E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7E2B629B4C005B83D3 /* SampleListView.swift */; }; E201EC812B631708005B83D3 /* Goal.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC802B631708005B83D3 /* Goal.swift */; }; - E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; }; + E27BC67A2B5D99AC003A8873 /* LocationSeriesDataTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSeriesDataTable.swift */; }; E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */; }; E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */; }; E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */; }; @@ -66,6 +66,7 @@ E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */; }; E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; }; E2FDFF2B2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF2A2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift */; }; + E2FDFF2D2B6D23670080A7B3 /* DataSeriesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF2C2B6D23670080A7B3 /* DataSeriesTable.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -100,7 +101,7 @@ E201EC7C2B62930E005B83D3 /* SamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplesTable.swift; sourceTree = ""; }; E201EC7E2B629B4C005B83D3 /* SampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleListView.swift; sourceTree = ""; }; E201EC802B631708005B83D3 /* Goal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Goal.swift; sourceTree = ""; }; - E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = ""; }; + E27BC6792B5D99AC003A8873 /* LocationSeriesDataTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSeriesDataTable.swift; sourceTree = ""; }; E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = ""; }; E27BC67F2B5E74D7003A8873 /* LocationSampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleListView.swift; sourceTree = ""; }; E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleDetailView.swift; sourceTree = ""; }; @@ -123,6 +124,7 @@ E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvenancesTable.swift; sourceTree = ""; }; E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; E2FDFF2A2B6D1E5E0080A7B3 /* HKWorkoutActivity+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutActivity+Comparable.swift"; sourceTree = ""; }; + E2FDFF2C2B6D23670080A7B3 /* DataSeriesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSeriesTable.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -210,7 +212,6 @@ E2FDFF232B6C509D0080A7B3 /* Tables */, E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */, E201EC802B631708005B83D3 /* Goal.swift */, - E27BC6792B5D99AC003A8873 /* LocationSample.swift */, E201EC7A2B6275CA005B83D3 /* Metadata.swift */, E201EC762B626FC1005B83D3 /* MetadataKey.swift */, E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */, @@ -254,6 +255,7 @@ E2FDFF232B6C509D0080A7B3 /* Tables */ = { isa = PBXGroup; children = ( + E27BC6792B5D99AC003A8873 /* LocationSeriesDataTable.swift */, E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */, E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */, E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */, @@ -262,6 +264,7 @@ E27BC68F2B5FCEA4003A8873 /* WorkoutActivitiesTable.swift */, E27BC68D2B5FCBD5003A8873 /* WorkoutEventsTable.swift */, 8850027A2B5C35BF00E7D4DB /* WorkoutsTable.swift */, + E2FDFF2C2B6D23670080A7B3 /* DataSeriesTable.swift */, ); path = Tables; sourceTree = ""; @@ -386,13 +389,14 @@ E2FDFF1C2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift in Sources */, 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */, E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */, - E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */, + E27BC67A2B5D99AC003A8873 /* LocationSeriesDataTable.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */, E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */, E2FDFF1A2B6BB6A40080A7B3 /* HKHealthStore+Interface.swift in Sources */, E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, + E2FDFF2D2B6D23670080A7B3 /* DataSeriesTable.swift in Sources */, E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */, E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */, E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */, diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift index 96eaef6..9f6d84f 100644 --- a/HealthImport/HealthDatabase.swift +++ b/HealthImport/HealthDatabase.swift @@ -15,6 +15,10 @@ final class HealthDatabase: ObservableObject { private let workoutsTable: WorkoutsTable + private let locationSamples: LocationSeriesDataTable + + private let dataSeries: DataSeriesTable + @Published var workouts: [Workout] = [] @@ -28,6 +32,8 @@ final class HealthDatabase: ObservableObject { self.database = database self.samples = .init(database: database) self.workoutsTable = .init(database: database) + self.locationSamples = .init(database: database) + self.dataSeries = .init(database: database) DispatchQueue.global().async { self.readAllWorkouts() @@ -48,11 +54,11 @@ final class HealthDatabase: ObservableObject { } func locationSamples(for activity: HKWorkoutActivity) throws -> [LocationSample] { - try LocationSample.locationSamples(from: activity.startDate, to: activity.currentEndDate, in: database) + try locationSamples.locationSamples(from: activity.startDate, to: activity.currentEndDate) } func locationSampleCount(for activity: HKWorkoutActivity) throws -> Int { - try LocationSample.locationSampleCount(from: activity.startDate, to: activity.currentEndDate, in: database) + try locationSamples.locationSampleCount(from: activity.startDate, to: activity.currentEndDate) } func samples(for activity: HKWorkoutActivity) throws -> [Sample.DataType : [Sample]] { @@ -103,6 +109,7 @@ final class HealthDatabase: ObservableObject { func createTables() throws { try samples.createAll() try workoutsTable.createAll() + try locationSamples.create(references: dataSeries) } } diff --git a/HealthImport/Model/LocationSample.swift b/HealthImport/Model/LocationSample.swift deleted file mode 100644 index b53e1ad..0000000 --- a/HealthImport/Model/LocationSample.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation -import SQLite -import CoreLocation - -typealias LocationSample = CLLocation - -extension LocationSample { - - private static let table = Table("location_series_data") - - /// `location_series_data[series_identifier]` <-> `workout_activities[ROW_ID]` - private static let columnSeriesIdentifier = Expression("series_identifier") - - private static let columnTimestamp = Expression("timestamp") - - private static let columnLongitude = Expression("longitude") - - private static let columnLatitude = Expression("latitude") - - private static let columnAltitude = Expression("altitude") - - private static let columnSpeed = Expression("speed") - - private static let columnCourse = Expression("course") - - private static let columnHorizontalAccuracy = Expression("horizontal_accuracy") - - private static let columnVerticalAccuracy = Expression("vertical_accuracy") - - private static let columnSpeedAccuracy = Expression("speed_accuracy") - - private static let columnCourseAccuracy = Expression("course_accuracy") - - private static let columnSignalEnvironment = Expression("signal_environment") - - static func locationSamples(for seriesId: Int, in database: Database) throws -> [LocationSample] { - try database.prepare(table.filter(columnSeriesIdentifier == seriesId)).map(location) - } - - static func locationSampleCount(for seriesId: Int, in database: Database) throws -> Int { - try database.scalar(table.filter(columnSeriesIdentifier == seriesId).count) - } - - static func locationSamples(from start: Date, to end: Date, in database: Database) throws -> [LocationSample] { - let startTime = start.timeIntervalSinceReferenceDate - let endTime = end.timeIntervalSinceReferenceDate - return try database.prepare(table.filter(columnTimestamp >= startTime && columnTimestamp <= endTime)).map(location) - } - - static func locationSampleCount(from start: Date, to end: Date, in database: Database) throws -> Int { - let startTime = start.timeIntervalSinceReferenceDate - let endTime = end.timeIntervalSinceReferenceDate - return try database.scalar(table.filter(columnTimestamp >= startTime && columnTimestamp <= endTime).count) - } - - private static func location(row: Row) -> LocationSample { - .init( - coordinate: .init( - latitude: row[columnLatitude], - longitude: row[columnLongitude]), - altitude: row[columnAltitude], - horizontalAccuracy: row[columnHorizontalAccuracy], - verticalAccuracy: row[columnHorizontalAccuracy], - course: row[columnCourse], - courseAccuracy: row[columnCourseAccuracy], - speed: row[columnSpeed], - speedAccuracy: row[columnSpeedAccuracy], - timestamp: .init(timeIntervalSinceReferenceDate: row[columnTimestamp]), - sourceInfo: .init()) - } - - static func createTable(in database: Connection) throws { - try database.execute("CREATE TABLE location_series_data (series_identifier INTEGER NOT NULL REFERENCES data_series(hfd_key) DEFERRABLE INITIALLY DEFERRED, timestamp REAL NOT NULL, longitude REAL NOT NULL, latitude REAL NOT NULL, altitude REAL NOT NULL, speed REAL NOT NULL, course REAL NOT NULL, horizontal_accuracy REAL NOT NULL, vertical_accuracy REAL NOT NULL, speed_accuracy REAL NOT NULL, course_accuracy REAL NOT NULL, signal_environment INTEGER NOT NULL, PRIMARY KEY (series_identifier, timestamp)) WITHOUT ROWID") - } - - static func insert(_ sample: LocationSample, in database: Connection, seriesId: Int) throws { - try database.run(table.insert( - columnSeriesIdentifier <- seriesId, - columnTimestamp <- sample.timestamp.timeIntervalSinceReferenceDate, - columnLongitude <- sample.coordinate.longitude, - columnLatitude <- sample.coordinate.latitude, - columnAltitude <- sample.altitude, - columnSpeed <- sample.speed, - columnCourse <- sample.course, - columnHorizontalAccuracy <- sample.horizontalAccuracy, - columnHorizontalAccuracy <- sample.verticalAccuracy, - columnSpeedAccuracy <- sample.speedAccuracy, - columnCourseAccuracy <- sample.courseAccuracy, - columnSignalEnvironment <- 1 - )) - } -} diff --git a/HealthImport/Model/Tables/DataSeriesTable.swift b/HealthImport/Model/Tables/DataSeriesTable.swift new file mode 100644 index 0000000..a6407ee --- /dev/null +++ b/HealthImport/Model/Tables/DataSeriesTable.swift @@ -0,0 +1,13 @@ +import Foundation +import SQLite + +struct DataSeriesTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + let table = Table("data_series") +} diff --git a/HealthImport/Model/Tables/LocationSeriesDataTable.swift b/HealthImport/Model/Tables/LocationSeriesDataTable.swift new file mode 100644 index 0000000..9276dba --- /dev/null +++ b/HealthImport/Model/Tables/LocationSeriesDataTable.swift @@ -0,0 +1,98 @@ +import Foundation +import SQLite +import CoreLocation + +typealias LocationSample = CLLocation + +struct LocationSeriesDataTable { + + private let database: Connection + + init(database: Connection) { + self.database = database + } + + let table = Table("location_series_data") + + /// `location_series_data[series_identifier]` <-> `workout_activities[ROW_ID]` + let seriesIdentifier = Expression("series_identifier") + + let timestamp = Expression("timestamp") + + let longitude = Expression("longitude") + + let latitude = Expression("latitude") + + let altitude = Expression("altitude") + + let speed = Expression("speed") + + let course = Expression("course") + + let horizontalAccuracy = Expression("horizontal_accuracy") + + let verticalAccuracy = Expression("vertical_accuracy") + + let speedAccuracy = Expression("speed_accuracy") + + let courseAccuracy = Expression("course_accuracy") + + let signalEnvironment = Expression("signal_environment") + + func locationSamples(for seriesId: Int) throws -> [LocationSample] { + try database.prepare(table.filter(seriesIdentifier == seriesId)).map(location) + } + + func locationSampleCount(for seriesId: Int) throws -> Int { + try database.scalar(table.filter(seriesIdentifier == seriesId).count) + } + + func locationSamples(from start: Date, to end: Date) throws -> [LocationSample] { + let startTime = start.timeIntervalSinceReferenceDate + let endTime = end.timeIntervalSinceReferenceDate + return try database.prepare(table.filter(timestamp >= startTime && timestamp <= endTime)).map(location) + } + + func locationSampleCount(from start: Date, to end: Date) throws -> Int { + let startTime = start.timeIntervalSinceReferenceDate + let endTime = end.timeIntervalSinceReferenceDate + return try database.scalar(table.filter(timestamp >= startTime && timestamp <= endTime).count) + } + + func location(row: Row) -> LocationSample { + .init( + coordinate: .init( + latitude: row[latitude], + longitude: row[longitude]), + altitude: row[altitude], + horizontalAccuracy: row[horizontalAccuracy], + verticalAccuracy: row[horizontalAccuracy], + course: row[course], + courseAccuracy: row[courseAccuracy], + speed: row[speed], + speedAccuracy: row[speedAccuracy], + timestamp: .init(timeIntervalSinceReferenceDate: row[timestamp]), + sourceInfo: .init()) + } + + func create(references dataSeries: DataSeriesTable) throws { + try database.execute("CREATE TABLE location_series_data (series_identifier INTEGER NOT NULL REFERENCES data_series(hfd_key) DEFERRABLE INITIALLY DEFERRED, timestamp REAL NOT NULL, longitude REAL NOT NULL, latitude REAL NOT NULL, altitude REAL NOT NULL, speed REAL NOT NULL, course REAL NOT NULL, horizontal_accuracy REAL NOT NULL, vertical_accuracy REAL NOT NULL, speed_accuracy REAL NOT NULL, course_accuracy REAL NOT NULL, signal_environment INTEGER NOT NULL, PRIMARY KEY (series_identifier, timestamp)) WITHOUT ROWID") + } + + func insert(_ sample: LocationSample, seriesId: Int) throws { + try database.run(table.insert( + seriesIdentifier <- seriesId, + timestamp <- sample.timestamp.timeIntervalSinceReferenceDate, + longitude <- sample.coordinate.longitude, + latitude <- sample.coordinate.latitude, + altitude <- sample.altitude, + speed <- sample.speed, + course <- sample.course, + horizontalAccuracy <- sample.horizontalAccuracy, + horizontalAccuracy <- sample.verticalAccuracy, + speedAccuracy <- sample.speedAccuracy, + courseAccuracy <- sample.courseAccuracy, + signalEnvironment <- 1 + )) + } +}